Skip to content

feat(cli): share the platform-binary resolver, add the Vale binary spec - #71

Open
thecodedrift wants to merge 17 commits into
mainfrom
openspec/add-vale-rule-engine
Open

feat(cli): share the platform-binary resolver, add the Vale binary spec#71
thecodedrift wants to merge 17 commits into
mainfrom
openspec/add-vale-rule-engine

Conversation

@thecodedrift

@thecodedrift thecodedrift commented Jul 28, 2026

Copy link
Copy Markdown
Member

Stack (root → tip):

Unit 1 of add-vale-rule-engine — the bottom of a four-unit stack that merges down. This PR carries the change proposal plus unit 1's implementation.

Stack

Unit Branch Scope
1 openspec/add-vale-rule-enginethis PR Binary resolution, Vale runner, CheckResult mapping
2 …-2-verify Fixture-based verify
3 …-3-orchestration Concurrent multi-engine dispatch and merge
4 …-4-topic Engine-selection topic, TOPICS entry, archive

Merging down, per the proposal: the Vale engine is only correct once check can dispatch to it — the runner without orchestration ships an engine that never executes, and orchestration without the runner dispatches to nothing. Units merge tip→root, then one protected merge of this branch to main.

Landed so far — task 1.1

findSgBinary() hard-coded ast-grep's packaging: the @ast-grep/cli prefix, the -gnu/-msvc suffixes, two bin spellings, and an ast-grep identity check. Vale needs the same search with different answers to all four, so the search moved to rules/platform-binary.ts and each engine supplies a spec.

The parameter that matters is toolchainSuffix. ast-grep publishes @ast-grep/cli-linux-x64-gnu; add-vale-binary-packages publishes @taskless/vale-linux-x64 with no libc suffix. Reusing ast-grep's naming for Vale would resolve nothing on Linux and surface as the ordinary "Vale is unavailable" message — a naming bug wearing the costume of a host that never installed it. test/platform-binary.test.ts pins the naming for both engines across every published platform and cross-checks the Vale names against the optionalDependencies actually declared, so a rename on either side fails there rather than at runtime.

The resolver returns {path, tried} rather than throwing, because the callers need different things from a miss. ast-grep is the only executor for sg rules, so findSgBinary() still throws; findValeBinary() returns undefined per D6b — a missing Vale binary makes one engine unavailable and must not abort the others. It caches the miss as well as the hit, since an absent Vale is the common case and each resolution spawns a subprocess per candidate.

ast-grep behaviour is unchanged: isAstGrepBinary and findSgBinary keep their signatures and their existing tests.

Still to come in this unit

  • 1.2 rules/vale/run.ts — invoke Vale against the committed config with JSON output, bounded by a subprocess timeout
  • 1.3 Vale JSON → CheckResult: rules. prefix stripping, severity normalization (suggestion → hint), range from Line/Span
  • 1.5 Tests: mapping, committed-config scoping, missing-binary and timeout paths

Verification

pnpm --filter @taskless/cli test456 passed; typecheck, lint, prettier clean.

Refs OSS-21

@thecodedrift thecodedrift added the skip-changeset PR intentionally ships no release note (bypasses the changeset requirement) label Jul 28, 2026
@thecodedrift
thecodedrift force-pushed the openspec/add-vale-rule-engine branch 3 times, most recently from 12dded7 to c452427 Compare August 3, 2026 23:16
thecodedrift added a commit that referenced this pull request Aug 4, 2026
A spec-only proposal is its own tip until its implementation is stacked on top,
so the gate demanded it archive a change nobody had built yet. #70, #71, and #72
have failed this check on every run since July 28 for exactly that reason —
weeks of red on PRs that were never merge-eligible, which is how a team learns
to stop reading red.

A draft cannot merge, so skipping it costs nothing: no unarchived change can
reach `main` either way. `ready_for_review` is added to the trigger types
because the default set (opened/synchronize/reopened) does not include it —
without that, a draft could be marked ready and merged on a green that was never
re-evaluated. That event is what preserves the guarantee.

The tip rule is unchanged for PRs that are ready for review.
@thecodedrift
thecodedrift force-pushed the openspec/add-vale-rule-engine branch from c452427 to 7b4d143 Compare August 6, 2026 02:51
thecodedrift added a commit that referenced this pull request Aug 10, 2026
Completes tasks 6.1-6.3 of add-vale-binary-packages, which were blocked until
the packages existed on npm. All six are now published at
3.17.1-20260810052605, so the CLI pins that exact version.

The pin is literal, per D8, and that needed checking rather than assuming:
`packages/*` makes the six vale packages workspace members at 0.0.0, so pnpm
could have linked them locally and silently reproduced the `workspace:*`
behaviour D8 rejects. The lockfile records a registry resolution and
node_modules points into the store at the pinned version, so the literal pin
means what it says.

6.2 verified end to end: createRequire from the CLI resolves the package, the
sibling `vale` is mode 755 and reports `vale version 3.17.1`, and only the
host-matching package installs — the other five are filtered by os/cpu.

6.3 verified against two real published stamps rather than by argument: with
dist-tags.latest at 3.17.1-20260810052605, a project pinned to
3.17.1-20260810045147 installs 045147. `^3.17.1` matches no version at all, so
a range cannot float into a prerelease.

Adds the changeset the proposal calls for ("Release impact: minor"). This is
why `skip-changeset` no longer applies to this PR: it was accurate while the
change was docs-only, and the pin makes it a real release — installing the CLI
now also delivers a binary.

7.3 stays open here by necessity; it edits add-vale-rule-engine's tasks, which
exist only on #71.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
thecodedrift added a commit that referenced this pull request Aug 10, 2026
Completes tasks 6.1-6.3 of add-vale-binary-packages, which were blocked until
the packages existed on npm. All six are now published at
3.17.1-20260810052605, so the CLI pins that exact version.

The pin is literal, per D8, and that needed checking rather than assuming:
`packages/*` makes the six vale packages workspace members at 0.0.0, so pnpm
could have linked them locally and silently reproduced the `workspace:*`
behaviour D8 rejects. The lockfile records a registry resolution and
node_modules points into the store at the pinned version, so the literal pin
means what it says.

6.2 verified end to end: createRequire from the CLI resolves the package, the
sibling `vale` is mode 755 and reports `vale version 3.17.1`, and only the
host-matching package installs — the other five are filtered by os/cpu.

6.3 verified against two real published stamps rather than by argument: with
dist-tags.latest at 3.17.1-20260810052605, a project pinned to
3.17.1-20260810045147 installs 045147. `^3.17.1` matches no version at all, so
a range cannot float into a prerelease.

Adds the changeset the proposal calls for ("Release impact: minor"). This is
why `skip-changeset` no longer applies to this PR: it was accurate while the
change was docs-only, and the pin makes it a real release — installing the CLI
now also delivers a binary.

7.3 stays open here by necessity; it edits add-vale-rule-engine's tasks, which
exist only on #71.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
thecodedrift added a commit that referenced this pull request Aug 10, 2026
Completes tasks 6.1-6.3 of add-vale-binary-packages, which were blocked until
the packages existed on npm. All six are now published at
3.17.1-20260810052605, so the CLI pins that exact version.

The pin is literal, per D8, and that needed checking rather than assuming:
`packages/*` makes the six vale packages workspace members at 0.0.0, so pnpm
could have linked them locally and silently reproduced the `workspace:*`
behaviour D8 rejects. The lockfile records a registry resolution and
node_modules points into the store at the pinned version, so the literal pin
means what it says.

6.2 verified end to end: createRequire from the CLI resolves the package, the
sibling `vale` is mode 755 and reports `vale version 3.17.1`, and only the
host-matching package installs — the other five are filtered by os/cpu.

6.3 verified against two real published stamps rather than by argument: with
dist-tags.latest at 3.17.1-20260810052605, a project pinned to
3.17.1-20260810045147 installs 045147. `^3.17.1` matches no version at all, so
a range cannot float into a prerelease.

Adds the changeset the proposal calls for ("Release impact: minor"). This is
why `skip-changeset` no longer applies to this PR: it was accurate while the
change was docs-only, and the pin makes it a real release — installing the CLI
now also delivers a binary.

7.3 stays open here by necessity; it edits add-vale-rule-engine's tasks, which
exist only on #71.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
@thecodedrift
thecodedrift marked this pull request as ready for review August 10, 2026 06:20
Copilot AI lite review requested due to automatic review settings August 10, 2026 06:20

Copilot AI left a comment

Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds a new OpenSpec change (openspec/changes/add-vale-rule-engine/) that specifies how the CLI should support Vale as a second static-tier rules engine, including engine routing, concurrent execution/merge semantics, and an engine-selection help topic.

Changes:

  • Introduces requirements for running Vale with committed config, mapping Vale JSON findings into CheckResult, and handling missing binary/timeout cases.
  • Specifies per-rule scoping via .vale.ini matchers and fixture-based Vale rule verification (pass//fail).
  • Adds proposal/design/tasks documentation and OpenSpec metadata for the change.

Reviewed changes

Copilot reviewed 9 out of 9 changed files in this pull request and generated 3 comments.

Show a summary per file
File Description
openspec/changes/add-vale-rule-engine/.openspec.yaml Declares OpenSpec change metadata (schema + created date).
openspec/changes/add-vale-rule-engine/proposal.md Summarizes motivation, scope, impact, and delivery shape for adding Vale + engine-selection topic.
openspec/changes/add-vale-rule-engine/design.md Captures key design decisions (scoping, layout, binary resolution strategy, mapping contract).
openspec/changes/add-vale-rule-engine/tasks.md Implementation task checklist for Vale engine execution, orchestration, and help topic registration/tests.
openspec/changes/add-vale-rule-engine/specs/cli-vale-rule-engine/spec.md Requirements for Vale execution model, config usage, result mapping, timeout, missing-binary behavior, and verify fixtures.
openspec/changes/add-vale-rule-engine/specs/cli-rule-routing/spec.md Requirements for the engine-selection knowledge topic and its constraints (distinct from route/trust tier).
openspec/changes/add-vale-rule-engine/specs/cli-rule-format/spec.md Requirements for Vale style layout under .taskless/vale/rules/ and rules. prefix normalization.
openspec/changes/add-vale-rule-engine/specs/cli-help/spec.md Requirements to register engine-selection in help + add cross-references from route/static.
openspec/changes/add-vale-rule-engine/specs/cli-check/spec.md Requirements for concurrent per-engine execution and merged results semantics.
Suppressed comments (1)

openspec/changes/add-vale-rule-engine/specs/cli-check/spec.md:18

  • This spec ends with a "## MODIFIED Requirements" heading but contains no modified requirements. Empty sections can confuse reviewers and can break tooling that expects content under each header.
## MODIFIED Requirements


💡 Add a code-review agent skill or configure MCP servers for context-aware, tailored reviews. Learn more in the docs.

Comment thread openspec/changes/add-vale-rule-engine/tasks.md Outdated
Comment thread openspec/changes/add-vale-rule-engine/tasks.md Outdated
Comment thread openspec/changes/add-vale-rule-engine/design.md
@thecodedrift
thecodedrift force-pushed the openspec/add-vale-rule-engine branch from 16f518f to c69649c Compare August 10, 2026 06:23
@thecodedrift
thecodedrift force-pushed the openspec/add-vale-rule-engine branch from c69649c to 32dce95 Compare August 10, 2026 19:35
@thecodedrift thecodedrift changed the title docs(openspec): add Vale as a second static-tier engine feat(cli): share the platform-binary resolver, add the Vale binary spec Aug 10, 2026
@thecodedrift thecodedrift removed the skip-changeset PR intentionally ships no release note (bypasses the changeset requirement) label Aug 11, 2026
@thecodedrift

Copy link
Copy Markdown
Member Author

@claude /review

@claude

claude Bot commented Aug 11, 2026

Copy link
Copy Markdown

Claude finished @thecodedrift's task in 2m 1s —— View job


Review checklist

  • Gather context (PR description, diff origin/main...HEAD, OpenSpec docs, existing Copilot review comments)
  • Run 8 parallel review passes (reuse/duplication, removed-behavior audit, cross-file tracing, CLAUDE.md conventions, altitude, efficiency, simplification, line-by-line diff scan)
  • Verify highest-severity findings against the actual source before posting
  • Post inline comments on concrete issues
  • Post overall assessment

Overall assessment

This is careful, well-documented work — the doc comments explaining why (toolchainSuffix, identity checks, the unavailable-vs-failed distinction) are genuinely good and made this review easier. One real bug and a couple of behavior regressions surfaced from the findSgBinaryresolvePlatformBinary generalization, worth fixing before this merges.

Bug (blocking): vale/map.ts — Vale's Line/Span are 1-based (the file's own doc comments say so), but CheckResult.range is documented and used elsewhere as 0-indexed (runtime/harness.ts:22-23, ast-grep's schema, and format.ts which unconditionally does +1 for display). toValeCheckResult writes Vale's values through unconverted, so every Vale finding will display one line and one column past its real location. The existing test in vale-map.test.ts currently pins the unconverted value, so this won't fail CI as written.

Real regressions from the platform-binary.ts extraction (scan.ts): the search order for ast-grep vs sg at the node_modules/.bin and PATH tiers is silently reversed from the original (which deliberately checked sg first — see the removed comment "matching the PATH search below"), the "put sg on your PATH" error hint lost its Windows .exe suffix, and the tried diagnostic list now shows the platform-package tier twice. None of this is caught by a test, since existing tests assert resolution succeeds/fails, not which candidate wins.

Design gap worth a second look: in vale/run.ts, child.on("error") treats a post-verification spawn failure as "unavailable" (a skip, per isValeFailure), even though findValeBinary() already proved the binary runs during resolution — so a binary that vanishes or loses permissions between resolution and execution is silently treated the same as "Vale isn't installed," rather than surfaced as the real problem it is. scan.ts's equivalent handler treats the analogous case as a hard failure instead.

Smaller correctness concern: vale/run.ts decodes stdout/stderr with chunk.toString() per Buffer chunk rather than a stateful decoder, which can corrupt a multi-byte UTF-8 character split across a chunk boundary — plausible given Vale lints free-form prose. Worst case this turns a clean run into a false "Vale produced output that is not JSON" failure.

Process note: the changeset (.changeset/vale-rule-engine.md) currently describes units 2-4 of the stack (orchestration, verify fixtures, help topic) that aren't in this PR's diff — the root CLAUDE.md's stacked-PR guidance asks for the changeset to grow incrementally with what's actually landed on each PR, not describe the end state up front.

Also flagged inline, lower priority: isValeFailure encodes severity as a function callers must remember to call rather than a field on ValeRunOutcome (currently unenforced since orchestration hasn't landed yet), and a documentation contradiction between map.ts and run.ts about whether Vale's config-error payload can arrive with exit 0.

Not flagged as blocking, but noted during review for awareness: vale/run.ts's timeout/kill/settle scaffolding duplicates the shape already built in runtime/invoke.ts (no shared helper); runAstGrepScan still has no timeout at all, unlike the new Vale runner; a few small dead/duplicated bits in platform-binary.ts (PlatformBinarySpec.label is unused, tried is computed twice via separate .map() calls in the hit/miss return paths); and findValeBinary's return type duplicates the already-exported PlatformBinaryResolution instead of reusing it. None of these affect correctness of this unit and are reasonable to leave for a follow-up.

Copilot's earlier review comments about the two tasks.md placeholders and a stale line-number reference in design.md are still open and worth a quick pass, but are cosmetic relative to the above.

thecodedrift added a commit that referenced this pull request Aug 11, 2026
…required

Two gaps in the "One changeset, at the bottom of the stack" guidance,
both found on the #71/#93/#94/#95 merging-down stack:

- The section said where the changeset lives but never when to create
  it. Document the trigger: write it on the base branch before cutting
  the children, so every child inherits a file it can append to.
- "Grow it incrementally" is merge-forward reasoning. Under merging
  down nothing lands until everything does, so a whole-change note is
  accurate at the only moment it is read. Scope the rule to forward,
  and state the constraint that holds in both shapes: the file must be
  in the bottom PR's own diff.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Comment thread packages/cli/src/rules/vale/map.ts
Comment thread packages/cli/src/rules/vale/run.ts
Comment thread packages/cli/src/rules/scan.ts
Comment thread packages/cli/src/rules/vale/run.ts Outdated
Comment thread .changeset/vale-rule-engine.md
Comment thread packages/cli/src/rules/vale/run.ts Outdated
Comment thread packages/cli/src/rules/vale/map.ts
@thecodedrift
thecodedrift force-pushed the openspec/add-vale-rule-engine branch from 90c8e50 to 6422854 Compare August 12, 2026 00:54
@thecodedrift

Copy link
Copy Markdown
Member Author

Re: @claude[bot] — "Claude finished @thecodedrift's task in 2m 1s … One real bug and a couple of behavior regressions surfaced from the findSgBinaryresolvePlatformBinary generalization"
#71 (comment)

Every finding here was verified against the source before changing anything, and all of them held. Addressed in c22d12a and 6422854:

  • The range bug was real, including your observation that vale-map.test.ts pinned the unconverted value so CI agreed with the defect. Fixed, and it turned up a second wrong pinning you had not seen: vale-run.test.ts:97 asserted range.start.line === 3 against the real binary.
  • The three platform-binary.ts regressions all reproduced against git show origin/main:...scan.ts. Search order restored, PATH hint gets its Windows .exe back via a new pathCommandName(), and tried no longer double-lists the platform-package tier. Also added the candidate-order test you correctly noted was missing.
  • child.on("error") now settles failed. Your argument decided it: resolution already executed --version, so this event means vanished-after-verification, not uninstalled.
  • UTF-8 decoding now uses one StringDecoder per stream.
  • The contradictory comments are reconciled to the measured behaviour — exit 2, stderr, empty stdout. map.ts was the accurate one; run.ts was wrong.
  • isValeFailure became a blocking field on ValeRunOutcome, per your inline note and the repo owner's independent call. Your prediction was borne out during the migration: a stale mocked outcome one PR up became a compile error rather than a silent behaviour change.

Declined, with reasons in-thread: the changeset scope (the CLAUDE.md guidance was amended by #97 — for a stack that merges down, incremental growth is a preference, not a correctness constraint), and Copilot's tasks.md/design.md notes (archived planning records, deliberately not rewritten to describe the outcome).

Not actioned, but recorded as follow-ups: runAstGrepScan still has no timeout, PlatformBinarySpec.label is unused, and valeUnavailableMessage hardcodes vale without the Windows suffix — now a one-liner via the exported pathCommandName.

— AI Coding Agent

thecodedrift and others added 17 commits August 11, 2026 19:45
…ngine

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01LdEhGzeQfSGJM3nKKNj7Bp
The ingest writer hardcodes .taskless/rules, so 0004 would relayout
existing rules under sg/ while the next `rule create` wrote back into
a directory no engine dispatches from.

The API carries no engine discriminator, so an engine-less payload is
ast-grep by definition — the default is permanent, matching what the
migration does to the same rules on disk. An unrecognized engine
fails loudly instead, since filing a Vale rule under sg/ surfaces as
a broken rule rather than version skew.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
…solution

Three additions to the Vale change:

The engine-selection knowledge topic ships here rather than as a
follow-on, since it names the engine directories this change creates.
Written engine-shaped, kept distinct from route's authoring
destination and from trust tier — sg and vale are both static-tier,
and conflating those axes is what the platform's binary classifier
got wrong.

Legacy .taskless/rules/ stays dispatchable alongside sg/rules/, so a
producer can keep using the pre-migration layout and its rules keep
running. That decouples this release from any consumer's.

Vale's binary resolves the way findSgBinary already does — resolve the
platform package from our own module context and exec beside it, no
lifecycle script. That code exists because the upstream postinstall
hardlink breaks under pnpm dlx, leaving a placeholder text file.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
An OpenSpec change has to land complete in one PR — the archive gate
requires a tip PR to leave openspec/changes/ empty — and this one had
grown to 44 tasks across ten groups. The layout half moved to
partition-rules-by-engine.

What stays is the engine and the knowledge about it: the Vale runner
and its scoping model, fixture verify, findings mapping, concurrent
multi-engine orchestration, and the engine-selection topic. 22 tasks.

The topic stays here rather than with the layout because it names
sg/vale/runtime as things an agent can actually choose between, which
is only true once Vale executes.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
Every one of these PRs carried skip-changeset while it was spec-only,
which becomes wrong the moment implementation lands. Stating the impact
in the proposal means the tip PR needs a changeset written, not a label
kept.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01CDv57zHq7abms3RReSQw6q
…s ship

add-vale-binary-packages published all six @taskless/vale-<os>-<cpu>
packages and pinned them as optionalDependencies, so tasks 1.1b-1.1e
(publishing, the mirror CI job, the musl decision, and the Vite
externalization check) are done elsewhere. Section 1 reduces to the
runtime resolution in task 1.1, which now carries the one detail the
removed subtasks held: the Vale packages have no libc suffix, so the
shared helper must not apply findSgBinary()'s -gnu mapping to them.

Closes task 7.3 of add-vale-binary-packages.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
…ed path

0.1 and 0.2 both hold now: partition-rules-by-engine is archived on main as
2026-08-02-partition-rules-by-engine, and all six @taskless/vale-* packages are
published at 3.17.1-20260810052605 and pinned in packages/cli
optionalDependencies. Record what satisfies them rather than leaving the boxes
open for someone to re-derive.

Task 3.1 sourced the topic from tmp/SEED-engine-selection-prose.md, which no
longer exists. Treating a missing file as a prerequisite would block the unit on
recovering it; the content requirements are already stated in 3.1-3.4, so name
those as the authority and rebuild the examples.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
…ng ref

tasks.md deferred the export decision to "D9", which does not exist — the
design records D1-D7 and the reasoning lives in D7. Point at D7.

The conditional it carried ("whichever of this change and
export-knowledge-prompts lands second adds the entry") has resolved:
export-knowledge-prompts landed first, so the entry falls here. Restate it as
fact and add task 3.7 for it.

3.7 is not bookkeeping. TOPICS union INTERNAL_TOPICS must account for every
canonical recipe on disk, and test/prompts.test.ts asserts that in both
directions, so adding help/<engine-selection>.txt without classifying it turns
the suite red. The registration therefore belongs in the same unit as the topic
file rather than a later one.

D7 now records why TOPICS rather than INTERNAL_TOPICS, since the difference is
functional rather than a label: TOPICS generates the exported PromptTopic union,
the argument getPrompt() accepts, and the keys of the published PROMPTS record,
so an entry there is what makes the topic renderable outside this package.
INTERNAL_TOPICS has no runtime effect — it is a ledger for the completeness
check. Neither affects `taskless help`. The consumer is the reason: engine
selection currently lives in the platform generator as a binary static|runtime
classifier that predates Vale, and exporting the entry is what lets the
generator render the same text instead of drifting from it. The accepted cost is
that an exported name is a promise held for a major version.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Task 1.1. `findSgBinary()` hard-coded ast-grep's packaging: the `@ast-grep/cli`
prefix, the `-gnu`/`-msvc` suffixes, the two bin spellings, and an `ast-grep`
identity check. Vale needs the same search with different answers to all four,
so the search moves to `rules/platform-binary.ts` and each engine supplies a
spec.

The parameter that matters is `toolchainSuffix`. ast-grep publishes
`@ast-grep/cli-linux-x64-gnu`; add-vale-binary-packages publishes
`@taskless/vale-linux-x64` with no libc suffix at all. Reusing ast-grep's naming
for Vale would resolve nothing on Linux and surface as the ordinary "Vale is
unavailable" message — a naming bug wearing the costume of a host that never
installed it. test/platform-binary.test.ts pins the naming for both engines
across every published platform, and cross-checks the Vale names against the
optionalDependencies actually declared in package.json so a rename on either
side fails there rather than at runtime.

The resolver returns `{path, tried}` instead of throwing, because the two
callers need different things from a miss: ast-grep is the only executor for
`sg` rules, so `findSgBinary()` keeps throwing, while `findValeBinary()` returns
undefined per D6b — a missing Vale binary makes one engine unavailable and must
not abort the others. `findValeBinary()` caches the miss as well as the hit,
since an absent Vale is the common case and each resolution spawns a subprocess
per candidate.

ast-grep behaviour is unchanged: `isAstGrepBinary` and `findSgBinary` keep their
signatures and their existing tests, all 456 green.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Tasks 1.2 and 1.3. `rules/vale/run.ts` invokes the resolved binary with
`--config .taskless/vale/.vale.ini --output=JSON --no-exit`, and
`rules/vale/map.ts` turns what comes back into CheckResults.

`--no-exit` is what makes the exit code readable: without it Vale exits non-zero
merely for having found something, which is indistinguishable from failing to
run. With it, non-zero means Vale itself failed.

The result is an outcome union rather than "results or throw". Three of its four
cases — unavailable, timeout, failed — are things the caller reports and keeps
going from, and under D6b the orchestration layer has to tell "Vale found
nothing" from "Vale never ran", a distinction an empty array erases.

Mapping decisions worth naming:

- `rules.` is stripped from the check name. The style directory is called
  `rules`, so Vale reports `rules.no-simply` for what a user wrote as
  `no-simply`; the prefix is an artifact of config layout, not part of the id.
- An unrecognized severity becomes `warning` rather than being dropped. A
  finding whose level we cannot read is still a finding.
- `fix` comes only from a `replace` action's replacement. Vale's other actions
  describe an edit whose result is not in the payload, so putting the action
  name there would offer to replace the user's match with the word "remove".

The JSON shape was captured from the real binary rather than assumed —
`Action: {Name: "", Params: null}`, `Span`, `Line`, capitalized keys — and the
tests run the real Vale rather than a stub, since what is being asserted is what
Vale does with a config. It ships as an optionalDependency for every platform we
publish, so those cases run in CI too; they skip only on an unsupported arch,
where a stub would be asserting our own mock.

Covers the non-verify half of 1.5: stripping and severity mapping, all three
scoping cases (include, exclude-wins, duplicate matchers merge), missing-binary,
and timeout. The verify half needs 1.4 and lands in unit 2; 1.5 is annotated
rather than ticked so that stays visible.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
…g shape

Two review points from unit 1.

`isValeFailure()` names which outcomes fail the check. The three non-ok cases
are not equivalent and collapsing them is wrong in both directions:
`unavailable` is a skip — an unsupported arch is an ordinary state, not evidence
the user's rules are wrong, and failing there makes `check` unrunnable on a
machine where ast-grep and runtime rules report fine. `timeout` and `failed` are
errors: Vale was present and asked to work, and reporting those as a skip lets a
broken rule file read as "no Vale findings", which is indistinguishable from a
clean run. Exported so orchestration (2.2) derives the exit code from one rule
rather than restating it.

The unknown-severity branch is future-proofing, now labelled as such: Vale
enforces its own vocabulary, rejecting anything outside
[suggestion warning error], so nothing reaches the default today.

Establishing that turned up Vale's config-error payload — a flat
{Line, Path, Text, Code, Span} object rather than findings-keyed-by-file.
Measured against the real binary: it goes to stderr with exit 2 and an empty
stdout, so the existing non-zero-exit branch already reports it and the shape
never reaches the mapper. (An earlier reading of "exit 0" was an artifact of
piping through `head`, which reports the pipe's status, with 2>&1 making stderr
look like stdout.)

The guard is kept as defence rather than a fix, because the failure mode if a
future Vale reports config errors on stdout is not a wrong answer but a crash:
mapping walks Object.entries over Line/Path/Code and calls .map on a number, and
an uncaught throw out of runVale would take the other engines with it (D6b).
Both the type guard and an Array.isArray check in the mapper are covered.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Vale is a vendored binary upgraded on its cadence, not ours, so the assumptions
our mapping rests on are worth asserting directly. vale-vendor-contract.test.ts
invokes Vale rather than runVale — a test through our wrapper would assert our
interpretation, which is what everything else already covers — and each case
names the code that breaks if the behaviour changes.

Writing it found three things that were wrong, two of them mine.

MATCHER PRECEDENCE. The spec said a disable "takes precedence over an enable,
independent of order". Measured against 3.17.1 that is false in both
directions. Precedence is positional: where two matchers both match a file the
LAST wins, and where one matcher assigns the same key twice — including across
duplicate [glob] sections, which Vale merges — the FIRST wins. So
[marketing/**] YES then [marketing/legacy/**] NO excludes legacy, and the same
two matchers in the opposite order do not. The spec now states that, requires a
disable to be declared after the enable it narrows, and warns that a repeated
assignment inside one glob is discarded — which matters for tooling that
appends to an existing matcher.

The unit-1 test covering this was named "lets a disable win over an enable,
regardless of order" and exercised one order. It passed while the claim it
named was false. Renamed to what it actually asserts, with the opposite order
pinned in the contract file.

EXIT CODES. Vale's exit code keys off severity, not off having found anything:
suggestion and warning exit 0 even without --no-exit, only error exits 1. So
--no-exit is load-bearing exactly for error-level rules, which is where dropping
it would hurt most — every check with a real violation reported as a failed
engine instead of as findings.

EMPTY RESULTS. Vale prints `{}`, not nothing, when it finds nothing. runVale's
empty-stdout branch is therefore insurance rather than the live path, and the
comment claiming otherwise is corrected.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
`require-changeset` only ever evaluates the bottom PR's own diff, so a changeset
written on the tip is invisible to the PR that actually merges — removing
`skip-changeset` from this PR turned the check red with the note sitting three
branches above it.

The stack merges down, so nothing lands until everything does and one note
describing the whole change is accurate at the only moment it is read. It is
moved here rather than duplicated; the tip's copy goes away as the units restack
onto this commit.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
…rch trail

Extracting the shared resolver quietly reversed ast-grep's original
ordering: it tried `sg` before `ast-grep` at both link-based tiers, with a
comment marking that deliberate, and iterating `binaryNames` in list order
flipped it. Nothing observable rides on it — both names link to the same
target and `isPlatformBinary` verifies whichever answers — which is exactly
why no test caught it. `resolvePlatformBinary` now reverses the list at the
link tiers, and a new case pins the order.

The platform-package tier is back to probing one name. Probing every
spelling there cost an extra `require.resolve` and listed the package label
twice in `tried`, so "Looked in: ..." read as if we had searched the same
place twice; `tried` now names each location once.

`findSgBinary`'s PATH advice is spelled for the platform again, via
`pathCommandName`, rather than hardcoding `sg` — a Windows user was being
told to install a name we would not have looked for.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
Four corrections to the Vale engine:

`toValeCheckResult` wrote Vale's 1-based `Line` and `Span` into
`CheckResult.range` unconverted. `range` is 0-indexed everywhere else —
ast-grep's native range passes straight through, the runtime harness
converts its 1-based `Finding` down, and `format.ts` adds 1 back for every
source at display time — so every Vale finding was reported one line and one
column further into the file than it is. Both now convert down, clamped at
0. The vendor-contract test still pins Vale's raw 1-based output, which is
correct as it stands.

stdout and stderr were accumulated with `chunk.toString()` per Buffer, which
replaces each half of a multi-byte UTF-8 sequence split across a chunk
boundary with U+FFFD. Vale lints prose full of curly quotes and em dashes,
and corruption landing inside JSON string escaping makes `JSON.parse` throw,
reporting a clean run as `failed`. Each stream now goes through a
`StringDecoder`, flushed on close.

A spawn `error` resolved as `unavailable`, an advisory skip. But
`findValeBinary` proved the binary runs by executing `--version` during
resolution, so an error on the scan spawn means it vanished, lost
permissions, or was quarantined in between — a real failure, as
`runAstGrepScan` treats the same event. Reclassified to `failed`.

`run.ts` claimed a config error arrives on stdout with exit 0; measurement
against the real binary says stderr with exit 2, as `map.ts` already
documented. The comment now says so and describes the guard as defensive.

Blocking-ness moves onto `ValeRunOutcome` as a literal-typed `blocking`
field, replacing the `isValeFailure` free function. Severity is a property
of what happened, not knowledge each caller must remember to apply, and
every engine we add should return the same self-describing shape.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01Jwc9FFroR3mTZ4hLiSkkX3
@thecodedrift
thecodedrift force-pushed the openspec/add-vale-rule-engine branch from 6422854 to d6ddbeb Compare August 12, 2026 03:17

@thecodedrift thecodedrift left a comment

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Small comments, nothing blocking. Wanted to explore how to stay as efficient as possible in the "check" flow because that's the one people will run via CI setups often hundreds of times a day. Anywhere we can be more efficient on this codepath is a small win.

@@ -0,0 +1,213 @@
import { spawnSync } from "node:child_process";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

We should really prefer async alternatives to sync options when possible

import { existsSync } from "node:fs";
import { createRequire } from "node:module";
import { spawn, spawnSync } from "node:child_process";
import { spawn } from "node:child_process";

Copy link
Copy Markdown
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Do we gain anything from switching to execa here? It's a little more feature complete, but I'd like to see if there are tradeoffs worth it WRT stdout/err capture

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

2 participants